package com.ar4j;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

import com.ar4j.sql.ActiveRecordRowMapper;
import com.ar4j.sql.ActiveRecordSqlParameterSource;
import com.ar4j.sql.ExtendedMapSqlParameterSource;
import com.ar4j.sql.SqlUtils;
import com.ar4j.type.ITypeConverter;
import com.ar4j.util.ClassUtils;

/**
 * A default implementation of an active record. Relies on another instance to actually hold data while
 * this class performs the functionality. What is required of the dataHolder is to have an implementation for
 * getProperty/setProperty.
 */
@SuppressWarnings("serial")
public class DefaultActiveRecord<T extends ActiveRecord<T>> implements ActiveRecord<T> {
  private ActiveRecordMetadata metadata;
  private ActiveRecordContext context;
  private T dataHolder;
  
  /**
   * Construct a new default active record, using the given base class metadata, context, and another
   * active record (fully proxied) as the object actually holding data.
   */
  public DefaultActiveRecord(ActiveRecordMetadata metadata, ActiveRecordContext context, T dataHolder) {
    this.metadata = metadata;
    this.context = context;
    this.dataHolder = dataHolder;
  }

  /**
   * @return the base class of the data holder of this default active record
   */
  @SuppressWarnings("unchecked")
  @Override
  public Class<T> getBaseClass() {
    return (Class<T>) metadata.getBaseClass();
  }
  
  /**
   * @return a new instance of the data holder's base class. executed via the active record factory.
   * @see ActiveRecordFactory
   */
  @Override
  public T newInstance() {
    return ActiveRecordFactory.getFactory().getActiveRecord(getBaseClass(), context);
  }
  
  /**
   * @return a new instance of the data holder's base class. executed via the active record factory.
   * @see ActiveRecordFactory
   */
  @Override
  public T newInstance(T prototype) {
    return ActiveRecordFactory.getFactory().getActiveRecord(prototype, context);
  }

  /**
   * @return a new instance of the data holder's base class. executed via the active record factory.
   * @see ActiveRecordFactory
   */
  @Override
  public T newInstance(Map<String, Object> propertyValues) {
    return ActiveRecordFactory.getFactory().getActiveRecord(getBaseClass(), propertyValues, context);
  }
  
  /**
   * @return a set of named representing all properties that the underlying data holder can hold
   */
  @Override
  public Set<String> getPropertyNames() {
    return metadata.getPropertyNames();
  }

  /**
   * @return the type of the property with the given name in the underlying data holder
   */
  @Override
  public Class<?> getPropertyType(String name) {
    return metadata.getPropertyTypeMap().get(name);
  }
  
  /**
   * @return true if the underlying data holder has a property with the given name, false otherwise
   */
  @Override
  public boolean hasProperty(String name) {
    return metadata.getPropertyNames().contains(name);
  }
  
  /**
   * @return the value of the property with the given name in the underlying data holder
   */
  @Override
  public Object getProperty(String name) {
    return dataHolder.getProperty(name);
  }
  
  /**
   * @return the value of the property with the given name in the underlying data holder, cast to the given required type
   */
  @Override
  public <E> E getProperty(String name, Class<E> requiredType) {
    return dataHolder.getProperty(name, requiredType);
  }
  
  /**
   * Sets the property with the given name in the underlying data holder
   */
  @Override
  public T setProperty(String name, Object value) {
    return dataHolder.setProperty(name, value);
  }
  
  /**
   * @return a map of all property name/value pairs in the underlying data holder
   */
  @Override
  public Map<String, Object> getPropertyMap() {
    Map<String, Object> out = new HashMap<String, Object>();
    for(String property : metadata.getPropertyNames()) {
      out.put(property, dataHolder.getProperty(property));
    }
    
    return out;
  }

  /**
   * Checks if the current default active record is equal to the given object. This is done by checking wether
   * all properties in the underlying data holder are equal to the given object's properties (it must be an ActiveRecord instance)
   */
  @Override
  public boolean equals(Object obj) {
    return isEqual(obj, false);
  }
  
  /**
   * Same as equals() except does not check for the value of the identifier column
   */
  @Override
  public boolean isSameContent(Object other) {
    return isEqual(other, true);
  }
  
  /**
   * @return a string representation of the underlying data holder's property names/values
   */
  @Override
  public String toString() {
    StringBuilder buffer = new StringBuilder();
    buffer.append(metadata.getBaseClass().getName()).append("{");
    boolean first = true;
    for(String name : metadata.getPropertyNames()) {
      if(!first) {
        buffer.append(",");
      } else {
        first = false;
      }
      
      buffer.append(name).append("=").append(dataHolder.getProperty(name));
    }
    buffer.append("}");
    
    return buffer.toString();
  }

  /**
   * @return a hash code calculated using the underlying data holder's property values
   */
  @Override
  public int hashCode() {
    HashCodeBuilder builder = new HashCodeBuilder();
    for(String name : metadata.getPropertyNames()) {
      Object value = dataHolder.getProperty(name);
      
      // enums are a special case, their hashCode is not consistent from VM start up to start up
      // instead we will use their name's hashCode
      if(value != null && value.getClass().isEnum()) {
        value = ((Enum<?>) value).name();
      }
      
      builder.append(value);
    }
    
    return builder.toHashCode();
  }

  /**
   * Synonym for newInstance(ActiveRecord)
   */
  @Override
  public T cloneRecord() {
    return newInstance(dataHolder);
  }
  
  /**
   * @return the name of the identifier property
   */
  @Override
  public String getIdentifierPropertyName() {
    return metadata.getIdentifierPropertyName();
  }
  
  /**
   * @return the type of the identifier property
   */
  @Override
  public Class<?> getIdentifierPropertyType() {
    return metadata.getPropertyTypeMap().get(metadata.getIdentifierPropertyName());
  }
  
  /**
   * @return the value of the identifier property using the underlying data holder's property name/values
   */
  @Override
  public Object getIdentifier() {
    return dataHolder.getProperty(metadata.getIdentifierPropertyName());
  }

  /**
   * Checks if the current record already exists in the RDBMS, this is true when the identifier is non null
   * and is found in the RDBMS via an existsByIdentifier(Object) operation.
   */
  @Override
  public boolean isNewRecord() {
    Object id = getIdentifier();
    if(id == null) {
      return true;
    }
    
    return !existsByIdentifier(id);
  }
  
  /**
   * @return the table name represented by the underlying data holder
   */
  @Override
  public String getTableName() {
    return metadata.getTableName();
  }

  /**
   * @return the type converter for the given property in the underlying data holder, or null if none is found
   */
  @Override
  public ITypeConverter<?, ?> getPropertyTypeConverter(String name) {
    return metadata.getPropertyTypeConvertorMap().get(name);
  }
  
  /**
   * @return the context within with the active record will perform its operations.
   * This context is modifiable and is a perfect way to set the active record's
   * datasource and other context properties.
   */
  @Override
  public ActiveRecordContext getContext() {
    return context;
  }
  
  /**
   * Performs a find operation by running a SELECT query in the RDBMS using the given parameters.
   * The parameter names are validated against the property names to make sure there is a mapping between them and 
   * column names represented by the underlying data holder. 
   */
  @Override
  public Collection<T> find(Map<String, ?> rawParameters, boolean lockForUpdate) {
    
    // build field list and 
    Collection<String> fields = new HashSet<String>();
    Collection<String> collectionFields = new HashSet<String>();
    Map<String, ?> parameters = getValidParameterMap(rawParameters);
    validateParametersAgainstMetadata(parameters, metadata, fields, collectionFields);
    
    // build parameters and get query
    String sql = SqlUtils.renderQueryWithQuerySuffix(metadata.getSelectQueryTemplate(), 
        context.getSchemaName(),
        metadata.getTableName(),
        composeSelectQuerySuffix(fields, collectionFields, metadata.getPropertyColumnNameMap(), lockForUpdate));
      
    ExtendedMapSqlParameterSource source = new ExtendedMapSqlParameterSource(parameters, metadata.getPropertyTypeConvertorMap());
    return context.getSimpleTemplate().query(sql, new ActiveRecordRowMapper<T>(metadata, context), source);
  }

  /**
   * Same as find(Map, PaginationAndSort, boolean) but does not provide an option to lock selected rows for update
   */
  @Override
  public Collection<T> find(Map<String, ?> parameters) {
    return find(parameters, false);
  }
  
  /**
   * A synonym for running a find operation with a single parameter where the name is equal to the
   * identifier column name and the value is equal to the given identifier value collection.
   */
  @Override
  public Collection<T> findAllByIdentifiers(Collection<?> ids) {
    Map<String, Object> parameters = Collections.singletonMap(metadata.getIdentifierPropertyName(), (Object) ids);
    Collection<T> results = find(parameters);
    if(results.size() != ids.size()) {
      throw new IncorrectResultSizeDataAccessException("Expected a number of records as given identifiers", ids.size(), results.size());
    }
    
    return results;
  }

  /**
   * A synonym for running a find operation with a single parameter where the name is equal to the
   * identifier column name and the value is equal to the given identifier. Makes there there is only
   * one result from the operation. Provides an option to lock the selected row for update.
   */
  @Override
  public T findByIdentifier(Object id, boolean lockForUpdate) {
    Map<String, Object> parameters = Collections.singletonMap(metadata.getIdentifierPropertyName(), id);
    Collection<T> results = find(parameters, lockForUpdate);
    if(results.size() != 1) {
      throw new IncorrectResultSizeDataAccessException("Expected a single record with the given identifier", 1, results.size());
    }
    
    return results.iterator().next();
  }

  /**
   * A synonym for running a find operation with a single parameter where the name is equal to the
   * identifier column name and the value is equal to the given identifier. Makes there there is only
   * one result from the operation.
   */
  @Override
  public T findByIdentifier(Object id) {
    return findByIdentifier(id, false);
  }
  
  /**
   * Performs a count operation by executing a COUNT query on the RDBMS with the given parameter name/values.
   * The parameter names are checked against the set of property names in the underlying data holder in the same
   * manner as the find(Map, PaginationAndSort, boolean) operation.
   */
  @Override
  public long count(Map<String, ?> rawParameters) {
    
    // build field list and
    Collection<String> fields = new HashSet<String>();
    Collection<String> collectionFields = new HashSet<String>();
    Map<String, ?> parameters = getValidParameterMap(rawParameters);
    validateParametersAgainstMetadata(parameters, metadata, fields, collectionFields);
    
    // build parameters and get query
    ExtendedMapSqlParameterSource source = new ExtendedMapSqlParameterSource(parameters, metadata.getPropertyTypeConvertorMap());
    String sql = SqlUtils.renderQueryWithWhere(metadata.getCountQueryTemplate(), 
        context.getSchemaName(), 
        metadata.getTableName(), 
        SqlUtils.composeWhereClause(fields, collectionFields, "T", metadata.getPropertyColumnNameMap()));
        
    return context.getSimpleTemplate().queryForLong(sql, source);
  }
  
  /**
   * Loads a new record with the underlying data holder's identifier value and copies all property values to the
   * underlying data holder.
   */
  @Override
  public T reload() {
    if(isNewRecord()) {
      throw new IllegalStateException("Cannot execute reload on a new active record, it does not have an identifier");
    }
    
    T other = findByIdentifier(getIdentifier());
    for(String property : metadata.getPropertyNames()) {
      dataHolder.setProperty(property, other.getProperty(property));
    }
    
    return dataHolder;
  }
  
  /**
   * Inserts a new record into the RDBMS if it had no identifier value or the identifier value was not found in the RDBMS,
   * performs and update otherwise.
   */
  @Override
  public T save() {
    
    // insert if its a new record, update otherwise
    ActiveRecordSqlParameterSource parameters = new ActiveRecordSqlParameterSource(dataHolder, metadata);
    if(isNewRecord()) {
      SimpleJdbcInsert insert = metadata.getSimpleJdbcInsertForDataSourceAndSchema(context);
      Number key = insert.executeAndReturnKey(parameters);
      Object id = ClassUtils.coerceValueToType(key, getIdentifierPropertyType());
      dataHolder.setProperty(metadata.getIdentifierPropertyName(), id);
    } else {
      String sql = SqlUtils.renderQuery(metadata.getUpdateQueryTemplate(), context.getSchemaName(), metadata.getTableName());
      context.getSimpleTemplate().update(sql, parameters);
    }
    
    reload();
    return dataHolder;
  }
  
  /**
   * A synonym for a find operation with no parameters.
   */
  @Override
  public Collection<T> findAll() {
    Map<String, Object> empty = Collections.emptyMap();
    return find(empty);
  }
  
  /**
   * A synonym for a count operation with no parameters.
   */
  @Override
  public long countAll() {
    Map<String, Object> empty = Collections.emptyMap();
    return count(empty);
  }

  /**
   * Performs a count operation with the given parameters and returns true if the result is at least one.
   */
  @Override
  public boolean exists(Map<String, ?> parameters) {
    long result = count(parameters);
    return (result > 0);
  }

  /**
   * Performs a count operation with the identifier property name as a parameter, the 
   * given collection as the value for that parameters. Returns true only if the number
   * of records found is equal to the size of the given collection.
   */
  @Override
  public boolean existsByIdentifier(Collection<?> ids) {
    long result = count(Collections.singletonMap(metadata.getIdentifierPropertyName(), ids));
    return(result == ids.size());
  }

  /**
   * Performs a count operation with the identifier property name as a parameter, the 
   * given identifier as the value for that parameters. Returns true only if the number
   * of records found is one.
   */
  @Override
  public boolean existsByIdentifier(Object id) {
    long result = count(Collections.singletonMap(metadata.getIdentifierPropertyName(), id));
    return(result == 1);
  }

  /**
   * A synonym for deleteByIdentifier(Object) where the value is the underlying data holder's
   * identifier value.
   */
  @Override
  public boolean delete() {
    if(deleteByIdentifier(getIdentifier())) {
      dataHolder.setProperty(metadata.getIdentifierPropertyName(), null);
      return true;
    }
    
    return false;
  }

  /**
   * Performs a delete operation by running a DELETE query on the RDBMS with the given parameter name/values.
   * The parameter names are checked against the set of property names in the underlying data holder in the same
   * manner as the find(Map, PaginationAndSort, boolean) operation.
   */
  @Override
  public int delete(Map<String, ?> rawParameters) {
    
    // process into parameters
    Collection<String> fields = new HashSet<String>();
    Collection<String> collectionFields = new HashSet<String>();
    Map<String, ?> parameters = getValidParameterMap(rawParameters);
    validateParametersAgainstMetadata(parameters, metadata, fields, collectionFields);
    
    // compose query and execute the delete
    ExtendedMapSqlParameterSource source = new ExtendedMapSqlParameterSource(parameters, metadata.getPropertyTypeConvertorMap());
    String sql = SqlUtils.renderQueryWithWhere(metadata.getDeleteQueryTemplate(), 
        context.getSchemaName(), 
        metadata.getTableName(), 
        SqlUtils.composeWhereClause(fields, collectionFields, null, metadata.getPropertyColumnNameMap()));
    
    return context.getSimpleTemplate().update(sql, source);
  }

  /**
   * A synonym for delete(Map) where the parameter name is the identifier property name and the value is the given
   * collection of identifier values
   */
  @Override
  public int deleteByIdentifier(Collection<?> ids) {
    int out = 0;
    if(ids.size() > 0) {
      out = delete(Collections.singletonMap(metadata.getIdentifierPropertyName(), ids));
    }
    
    return out;
  }

  /**
   * A synonym for delete(Map) where the parameter name is the identifier property name and the value is
   * the given identifier value
   */
  @Override
  public boolean deleteByIdentifier(Object id) {
    int result = delete(Collections.singletonMap(metadata.getIdentifierPropertyName(), id));
    return (result == 1);
  }

  /**
   * Performs an equals comparison between the underlying data holder and the given object.
   * Makes sure that the object is an active record and is of the same base class before proceeding
   * to compare its property values. Optionally ignored the identifier property.
   */
  private boolean isEqual(Object other, boolean ignoreIdField) {
    
    // not comparing to an active record
    if(other != null && !ActiveRecord.class.isAssignableFrom(other.getClass())) {
      return false;
    }
    
    ActiveRecord<?> lhs = (ActiveRecord<?>) dataHolder;
    ActiveRecord<?> rhs = (ActiveRecord<?>) other;
    
    if (lhs == rhs) { return true; }
    if (lhs == null || rhs == null) { return false; }
    if(!lhs.getBaseClass().equals(rhs.getBaseClass())) { return false; }
    
    EqualsBuilder builder = new EqualsBuilder();
    for(String property : metadata.getPropertyNames()) {
      if(ignoreIdField && metadata.getIdentifierPropertyName().equals(property)) { continue; }
      builder.append(lhs.getProperty(property), rhs.getProperty(property));
    }
    
    return builder.isEquals();
  }
  
  /**
   * Composes the WHERE ... FOR UPDATE suffix of a SELECT query
   */
  private String composeSelectQuerySuffix(Collection<String> fields, Collection<String> collectionFields, Map<String, String> fieldToColumnMap, boolean forUpdate) {
    StringBuilder sql = new StringBuilder();
    
    if(fields.size() > 0) {
      SqlUtils.appendWhereClause(sql, fields, collectionFields, "T", fieldToColumnMap);
    }
    
    if(forUpdate) {
      sql.append(" FOR UPDATE ");
    }
    
    return sql.toString();
  }
  
  /**
   * Goes through the given parameters, makes sure that all parameter names are properties in the provided
   * active record metadata. Records field names and collection field flags in the given maps.
   */
  private void validateParametersAgainstMetadata(Map<String, ?> parameters, ActiveRecordMetadata metadata, 
      Collection<String> fields, Collection<String> collectionFields) {
    
    Set<String> propertyNames = metadata.getPropertyNames();
    for(Map.Entry<String, ?> entry : parameters.entrySet()) {
      if(!propertyNames.contains(entry.getKey())) {
        throw new IllegalArgumentException("Unknown field in parameter map: " + entry.getKey() + " for base class: " + metadata.getBaseClass());
      }
      
      fields.add(entry.getKey());
      
      Object value = entry.getValue();
      if(value != null && (value.getClass().isArray() || Collection.class.isAssignableFrom(value.getClass()))) {
        collectionFields.add(entry.getKey());
      }
    }
  }
  
  /**
   * Performs processing on the parameter map before its values are used. Makes a new copy of
   * the given parameter map and converts any arrays to collections.
   */
  private Map<String, ?> getValidParameterMap(Map<String, ?> parameters) {
    Map<String, Object> out = new HashMap<String, Object>(parameters.size());
    for(Map.Entry<String, ?> entry : parameters.entrySet()) {
      Object value = entry.getValue();
      
      // special handling of array parameters
      if(value != null && value.getClass().isArray()) {
        value = Arrays.asList(value);
      }
      
      out.put(entry.getKey(), value);
    }
    
    return out;
  }

}
